/*
 * Unidata Platform Community Edition
 * Copyright (c) 2013-2020, UNIDATA LLC, All rights reserved.
 * This file is part of the Unidata Platform Community Edition software.
 *
 * Unidata Platform Community Edition is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * Unidata Platform Community Edition is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
 */

package org.unidata.mdm.search.context;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

import javax.annotation.Nonnull;

import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.collections4.MapUtils;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.unidata.mdm.search.type.IndexField;
import org.unidata.mdm.search.type.IndexType;
import org.unidata.mdm.search.type.query.FormSearchQuery;
import org.unidata.mdm.search.type.query.MatchSearchQuery;
import org.unidata.mdm.search.type.query.SearchQuery;
import org.unidata.mdm.search.type.query.SearchQuery.SearchQueryType;
import org.unidata.mdm.search.type.query.StringSearchQuery;
import org.unidata.mdm.search.type.sort.SortField;
import org.unidata.mdm.system.context.InputContainer;

/**
 * @author Mikhail Mikhailov
 *         Search execution context.
 */
public class SearchRequestContext implements InputContainer, TypedSearchContext {

    public static final int MAX_PAGE_SIZE = 50000;
    /**
     * The queries.
     */
    private final Map<SearchQueryType, SearchQuery> queries;
    /**
     * The special post filter.
     */
    private final FormSearchQuery filter;
    /**
     * The field, parent <-> child queries are joined by, if children have return fields.
     */
    private final IndexField joinBy;
    /**
     * Query type name.
     */
    private final String entity;
    /**
     * Score.
     */
    private final Float score;
    /**
     * Return source or not.
     */
    private final boolean source;
    /**
     * Fields to return.
     */
    private final List<String> returnFields;
    /**
     * A map-like "return as value" field set.
     */
    private final Set<String> returnAsValues;
    /**
     * Fields which will be manage search result order.
     */
    private final Collection<SortField> sortFields;
    /**
     * The text
     */
    private final String text;
    /**
     * Sort field values for last record in last search.
     */
    private final List<Object> searchAfter;
    /**
     * Max hit count to return.
     */
    private final int count;
    /**
     * Page number, 0 based.
     */
    private final int page;
    /**
     * Return total count or not.
     */
    private final boolean totalCount;
    /**
     * Return total count only and no other results.
     */
    private final boolean countOnly;
    /**
     * Return all paged results, if no query string given.
     */
    private final boolean fetchAll;
    /**
     * The storage id to use. Overrides the system one.
     */
    private final String storageId;
    /**
     * Skip or add etalon id. Add is the default.
     */
    private final boolean skipEtalonId;
    /**
     * Search as you type
     */
    private final boolean sayt;
    /**
     * Use scroll scan mechanism
     */
    private final boolean scrollScan;
    /**
     * The search type
     */
    private final IndexType type;
    /**
     * Aggregations collection.
     */
    private final Collection<AggregationSearchContext> aggregations;
    /**
     * Netsed path.
     */
    private final String nestedPath;
    /**
     * Routing hints to hit only those shards, which really contain the value.
     */
    private final List<String> routings;
    /**
     *  Shard number use for preferences
     */
    private final Integer shardNumber;
    /**
     * Inner hits
     */
    private final List<NestedSearchRequestContext> nestedSearch;
    /**
     * Run user exits or not
     */
    private final boolean runExits;
    /**
     * Score query.
     */
    private boolean scoreEnabled;
    /**
     * true - must, false - should
     */
    private final boolean must;
    /**
     * Score for fields.
     */
    private final Map<String, Float> scoreFields;
    /**
     * Building constructor.
     *
     * @param builder the builder to use.
     */
    private SearchRequestContext(SearchRequestContextBuilder builder) {

        super();

        Objects.requireNonNull(builder.entity, "Index [entity] must not be null");

        this.entity = builder.entity;
        this.score = builder.score;
        this.source = builder.source;
        this.returnFields = builder.returnFields;
        this.returnAsValues = builder.returnAsValues;
        this.sortFields = builder.sortFields;
        this.text = builder.text;
        this.page = builder.page;
        this.count = builder.count;
        this.totalCount = builder.totalCount;
        this.countOnly = builder.countOnly;
        this.fetchAll = builder.fetchAll;
        this.storageId = builder.storageId;
        this.skipEtalonId = builder.skipEtalonId;
        this.type = builder.type;
        this.sayt = builder.sayt;
        this.scrollScan = builder.scrollScan;
        this.aggregations = builder.aggregations;
        this.nestedPath = builder.nestedPath;
        this.routings = builder.routings;
        this.runExits = builder.runExits;
        this.searchAfter = builder.searchAfter;
        this.shardNumber = builder.shardNumber;
        this.nestedSearch = builder.nestedSearch;
        this.must = builder.must;
        this.scoreFields = builder.scoreFields;
        this.queries = builder.queries;
        this.filter = builder.filter;
        this.joinBy = builder.joinBy;
        this.scoreEnabled = builder.scoreEnabled;
    }
    /**
     * @return the queries
     */
    public Map<SearchQueryType, SearchQuery> getQueries() {
        return Objects.isNull(queries) ? Collections.emptyMap() : queries;
    }
    /**
     * Gets the match query.
     * @return query or null
     */
    public MatchSearchQuery getMatchQuery() {
        return (MatchSearchQuery) queries.get(SearchQueryType.MATCH);
    }
    /**
     * Gets the form query.
     * @return query or null
     */
    public FormSearchQuery getFormQuery() {
        return (FormSearchQuery) queries.get(SearchQueryType.FORM);
    }
    /**
     * Gets the form query.
     * @return query or null
     */
    public StringSearchQuery getStringQuery() {
        return (StringSearchQuery) queries.get(SearchQueryType.STRING);
    }
    /**
     * Gets the filter.
     * @return
     */
    public FormSearchQuery getFilter() {
        return filter;
    }
    /**
     * @return the joinBy
     */
    public IndexField getJoinBy() {
        return joinBy;
    }

    public boolean hasQueries() {
        return MapUtils.isNotEmpty(queries);
    }

    public boolean hasFilter() {
        return Objects.nonNull(filter);
    }

    public boolean hasMatchQuery() {
        return MapUtils.isNotEmpty(queries) && queries.get(SearchQueryType.MATCH) != null;
    }

    public boolean hasFormQuery() {
        return MapUtils.isNotEmpty(queries) && queries.get(SearchQueryType.FORM) != null;
    }

    public boolean hasStringQuery() {
        return MapUtils.isNotEmpty(queries) && queries.get(SearchQueryType.STRING) != null;
    }

    public boolean hasJoinBy() {
        return Objects.nonNull(joinBy);
    }

    public boolean hasReturnFields() {
        return CollectionUtils.isNotEmpty(returnFields);
    }

    /**
     * @return the type
     */
    @Override
    public String getEntity() {
        return entity;
    }

    /**
     * @return the storageId
     */
    @Override
    public String getStorageId() {
        return storageId;
    }
    /**
     * @return the score
     */
    public Float getScore() {
        return score;
    }
    /**
     * @return the returnFields
     */
    public List<String> getReturnFields() {
        return returnFields;
    }
    /**
     * @return the returnAsValues
     */
    public Set<String> getReturnAsValues() {
        return CollectionUtils.isEmpty(returnAsValues) ? Collections.emptySet() : returnAsValues;
    }
    /**
     * @return the count
     */
    public int getCount() {
        return count;
    }
    /**
     * @return the page
     */
    public int getPage() {
        return page;
    }
    /**
     * @return the source
     */
    public boolean isSource() {
        return source;
    }
    /**
     * @return the totalCount
     */
    public boolean isTotalCount() {
        return totalCount;
    }
    /**
     * @return the countOnly
     */
    public boolean isCountOnly() {
        return countOnly;
    }
    /**
     * @return the fetchAll
     */
    public boolean isFetchAll() {
        return fetchAll;
    }
    /**
     * @return the skipEtalonId
     */
    public boolean isSkipEtalonId() {
        return skipEtalonId;
    }
    /**
     * @return the scroll scan
     */
    public boolean isScrollScan() {
        return scrollScan;
    }
    /**
     * @return true if nothing supposed to be search
     */
    public boolean isEmpty() {
        return !hasQueries() && !hasFilter() && !isNested();
    }
    /**
     * @return true if it is "Search as you type", otherwise false
     */
    public boolean isSayt() {
        return sayt;
    }
    /**
     * @return true if it is nested query / request
     */
    public boolean isNested() {
        return Objects.nonNull(this.nestedPath);
    }
    /**
     * @return run exits or not
     */
    public boolean isRunExits() {
        return runExits;
    }
    /**
     * @return the returnEtalon
     */
    public boolean isScoreEnabled() {
        return scoreEnabled;
    }
    public boolean isMust(){
        return must;
    }
    /**
     * @return fields which will be used for result sorting.
     */
    public Collection<SortField> getSortFields() {
        return sortFields;
    }
    /**
     * @return the search after values
     */
    public List<Object> getSearchAfter() {
        return searchAfter;
    }
    /**
     * @return the scroll scan
     */
    public Integer getShardNumber() {
        return shardNumber;
    }
    /**
     * @return the scroll scan
     */
    public List<NestedSearchRequestContext> getNestedSearch() {
        return nestedSearch;
    }
    /**
     * Type of requested entity in index.
     * @return search
     */
    public IndexType getType() {
        return type;
    }
    /**
     * @return the nestedPath
     */
    public String getNestedPath() {
        return nestedPath;
    }

    /**
     * @return the aggregations
     */
    public Collection<AggregationSearchContext> getAggregations() {
        return aggregations;
    }

    /**
     * @return the routings
     */
    public List<String> getRoutings() {
        return routings;
    }


    /**
     * @return the scoreFields
     */
    public Map<String, Float> getScoreFields() {
        return MapUtils.isEmpty(scoreFields) ? Collections.emptyMap() : scoreFields;
    }


    /**
     * {@inheritDoc}
     */
    @Override
    public String toString() {

        StringBuilder b = new StringBuilder()
            .append(getClass().getSimpleName())
            .append(": {")
            .append("entity = ").append(entity == null ? "null" : entity).append(", ")
            .append("score = ").append(score).append(", ")
            .append("source = ").append(source).append(", ")
            .append("returnFields = ").append(returnFields == null ? "null" : returnFields.toString()).append(", ")
            .append("searchAfter = ").append(searchAfter == null ? "null" : searchAfter.toString()).append(", ")
            .append("text = ").append(text == null ? "null" : text).append(", ")
            .append("count = ").append(count).append(", ")
            .append("page = ").append(page).append(", ")
            .append("totalCount = ").append(totalCount).append(", ")
            .append("countOnly = ").append(countOnly).append(", ")
            .append("must = ").append(must).append(", ")
            .append("}");

        return b.toString();
    }
    /**
     * Search all types, simple searches only.
     * @return builder instance for index operation.
     */
    @Nonnull
    public static SearchRequestContextBuilder builder(@Nonnull String entity) {
        SearchRequestContextBuilder builder = new SearchRequestContextBuilder();
        builder.entity = entity;
        return builder;
    }
    /**
     * Search all indexes in a particular type.
     * @return general builder builder
     */
    @Nonnull
    public static SearchRequestContextBuilder builder(@Nonnull IndexType type) {
        SearchRequestContextBuilder builder = new SearchRequestContextBuilder();
        builder.type = type;
        return builder;
    }

    /**
     * By default search will be applied to etalon data.
     * Note: should be called entity method
     * Note: better will be used other static fabric methods
     *
     * @return general builder builder
     */
    @Nonnull
    public static SearchRequestContextBuilder builder(@Nonnull IndexType type, @Nonnull String entity) {
        SearchRequestContextBuilder builder = new SearchRequestContextBuilder();
        builder.type = type;
        builder.entity = entity;
        return builder;
    }
    /**
     * By default search will be applied to etalon data.
     * Note: should be called entity method
     * Note: better will be used other static fabric methods
     *
     * @return general builder builder
     */
    @Nonnull
    public static SearchRequestContextBuilder builder(@Nonnull IndexType type, @Nonnull String entity, String storageId) {
        SearchRequestContextBuilder builder = new SearchRequestContextBuilder();
        builder.type = type;
        builder.entity = entity;
        builder.storageId = storageId;
        return builder;
    }
    /**
     * Context builder.
     *
     * @author Mikhail Mikhailov
     */
    public static class SearchRequestContextBuilder implements SearchInputCollector {
        /**
         * The queries.
         */
        private Map<SearchQueryType, SearchQuery> queries;
        /**
         * The special post filter.
         */
        private FormSearchQuery filter;
        /**
         * The field, parent <-> child queries are joined by, if children have return fields.
         */
        private IndexField joinBy;
        /**
         * Type to operate on.
         */
        private String entity;
        /**
         * Score.
         */
        private Float score;
        /**
         * Return source or not.
         */
        private boolean source;
        /**
         * Fields to return.
         */
        private List<String> returnFields;
        /**
         * A map-like "return as value" field set.
         */
        private Set<String> returnAsValues;
        /**
         * Fields which will be manage search result order.
         */
        private Collection<SortField> sortFields = Collections.emptyList();
        /**
         * The text.
         */
        private String text;
        /**
         * Objects count.
         */
        private int count;
        /**
         * Page number, 0 based.
         */
        private int page;
        /**
         * Return total count or not.
         */
        private boolean totalCount;
        /**
         * Return total count only and no other results.
         */
        private boolean countOnly;
        /**
         * Return all paged results, if no query string given.
         */
        private boolean fetchAll;
        /**
         * The storage id to use. Overrides the system one.
         */
        private String storageId;
        /**
         * Skip or add etalon id. Add is the default.
         */
        private boolean skipEtalonId;
        /**
         * Search as you type
         */
        private boolean sayt = false;
        /**
         * Use scroll scan mechanism
         */
        private boolean scrollScan = false;
        /**
         * Type of entity
         */
        private IndexType type;
        /**
         * Aggregations collection.
         */
        private Collection<AggregationSearchContext> aggregations;
        /**
         * Routing hints to hit only those shards, which really contain the value.
         */
        private List<String> routings;
        /**
         * run exits or not
         */
        private boolean runExits = false;
        /**
         * Sort field values for last record in last search.
         */
        private List<Object> searchAfter;
        /**
         * Shard number for request
         */
        private Integer shardNumber;
        /**
         * Score enabled flag
         */
        private boolean scoreEnabled;
        /**
         * Netsed path.
         */
        private String nestedPath;
        /**
         * Inner hits
         */
        private List<NestedSearchRequestContext> nestedSearch;
        /**
         * true - must, false - should
         */
        private boolean must = true;
        /**
         * Score for fields.
         */
        private Map<String, Float> scoreFields;
        /**
         * Search type
         */
        private SearchRequestContextBuilder() {
            super();
        }
        /**
         * Adds a search query to this context.
         * @param q the query
         * @return self
         */
        public SearchRequestContextBuilder query(SearchQuery q) {

            if (Objects.nonNull(q)) {

                if (Objects.isNull(queries)) {
                    queries = new EnumMap<>(SearchQueryType.class);
                }

                if (q.getType() == SearchQueryType.FORM && queries.containsKey(SearchQueryType.FORM)) {
                    FormSearchQuery existing = (FormSearchQuery) queries.get(SearchQueryType.FORM);
                    existing.join((FormSearchQuery) q);
                } else {
                    queries.put(q.getType(), q);
                }
            }

            return this;
        }
        /**
         * Adds a search query to this context.
         * @param q the query
         * @return self
         */
        public SearchRequestContextBuilder filter(FormSearchQuery q) {
            if (Objects.nonNull(q)) {
                if (Objects.isNull(filter)) {
                    filter = q;
                } else {
                    filter.join(q);
                }
            }
            return this;
        }
        /**
         * The field, parent <-> child queries are joined by, if children have return fields.
         * @param joinBy the field to join requests by
         * @return self
         */
        public SearchRequestContextBuilder joinBy(IndexField joinBy) {
            this.joinBy = joinBy;
            return this;
        }
        /**
         * Sets the score.
         * @param score the score
         * @return this
         */
        public SearchRequestContextBuilder score(float score) {
            this.score = score;
            return this;
        }
        /**
         * Sets the source field.
         * @param source return source or not.
         * @return this
         */
        public SearchRequestContextBuilder source(boolean source) {
            this.source = source;
            return this;
        }
        /**
         * Sets the return fields.
         * @param returnFields the return fields to use
         * @return this
         */
        public SearchRequestContextBuilder returnFields(List<String> returnFields) {
            if (CollectionUtils.isNotEmpty(returnFields)) {
                returnFields(returnFields.toArray(String[]::new));
            }
            return this;
        }

        /**
         * Sets the return fields.
         * @param returnFields the return fields to use
         * @return this
         */
        public SearchRequestContextBuilder returnFields(String... returnFields) {
            if (ArrayUtils.isNotEmpty(returnFields)) {

                if (this.returnFields == null) {
                    this.returnFields = new ArrayList<>();
                }

                for (int i = 0; i < returnFields.length; i++) {
                    if (StringUtils.isNotBlank(returnFields[i])) {
                        this.returnFields.add(StringUtils.strip(returnFields[i]));
                    }
                }
            }
            return this;
        }

        /**
         * Sets the return as values fields.
         * @param returnAsValues the return as values fields to use
         * @return this
         */
        public SearchRequestContextBuilder returnAsValues(List<String> returnAsValues) {
            if (CollectionUtils.isNotEmpty(returnAsValues)) {
                returnAsValues(returnAsValues.toArray(String[]::new));
            }
            return this;
        }

        public SearchRequestContextBuilder returnAsValues(String... returnAsValues) {
            if (ArrayUtils.isNotEmpty(returnAsValues)) {

                if (this.returnAsValues == null) {
                    this.returnAsValues = new HashSet<>();
                }

                for (int i = 0; i < returnAsValues.length; i++) {
                    if (StringUtils.isNotBlank(returnAsValues[i])) {
                        this.returnAsValues.add(StringUtils.strip(returnAsValues[i]));
                    }
                }
            }
            return this;
        }
        /**
         * Sets the search type.
         *
         * @param type the index type to use
         * @return this
         */
        public SearchRequestContextBuilder type(IndexType type) {
            this.type = type;
            return this;
        }
        /**
         * Gets the index type, currently set.
         * @return type
         */
        public IndexType getType() {
            return type;
        }
        /**
         * Adds sorting.
         * @param sortFields sorting fields
         * @return self
         */
        public SearchRequestContextBuilder sorting(Collection<SortField> sortFields) {
            this.sortFields = sortFields;
            return this;
        }
        /**
         * SAYT mark.
         * @param sayt marks this context as SAYT
         * @return self
         */
        public SearchRequestContextBuilder sayt(boolean sayt) {
            this.sayt = sayt;
            return this;
        }
        /**
         * Sets page number, 0 based.
         *
         * @param page the page
         * @return this
         */
        public SearchRequestContextBuilder page(int page) {
            this.page = page;
            return this;
        }
        /**
         * Sets the max count to return.
         * Max count to return can't be more {@value #MAX_PAGE_SIZE}.
         *
         * @param count the count
         * @return this
         */
        public SearchRequestContextBuilder count(int count) {

            this.count = Math.min(MAX_PAGE_SIZE, count);
            return this;
        }
        /**
         * Sets the totalCount field.
         *
         * @param totalCount return totalCount or not.
         * @return this
         */
        public SearchRequestContextBuilder totalCount(boolean totalCount) {
            this.totalCount = totalCount;
            return this;
        }
        /**
         * Sets the countOnly field.
         *
         * @param countOnly return countOnly or not.
         * @return this
         */
        public SearchRequestContextBuilder countOnly(boolean countOnly) {
            this.countOnly = countOnly;
            return this;
        }
        /**
         * Sets the fetchAll field.
         *
         * @param fetchAll return all, if no query string set, or not.
         * @return this
         */
        public SearchRequestContextBuilder fetchAll(boolean fetchAll) {
            this.fetchAll = fetchAll;
            return this;
        }
        /**
         * @param entityName - entity name
         * @return self
         */
        public SearchRequestContextBuilder entity(String entityName){
            this.entity = entityName;
            return this;
        }
        /**
         * Overrides default storage id.
         *
         * @param storageId the storage id to use
         * @return self
         */
        public SearchRequestContextBuilder storageId(String storageId) {
            this.storageId = storageId;
            return this;
        }
        /**
         * Skip or add etalon ID to return fields. Add is the default.
         * This is used for type, which don't have etalon id.
         *
         * @param skipEtalonId true or false
         * @return self
         */
        public SearchRequestContextBuilder skipEtalonId(boolean skipEtalonId) {
            this.skipEtalonId = skipEtalonId;
            return this;
        }
        /**
         * use scroll scan mechanism
         *
         * @param scrollScan true or false
         * @return self
         */
        public SearchRequestContextBuilder scrollScan(boolean scrollScan) {
            this.scrollScan = scrollScan;
            return this;
        }
        /**
         * Generate nested query with given path.
         *
         * @param nestedPath path
         * @return self
         */
        public SearchRequestContextBuilder nestedPath(String nestedPath) {
            this.nestedPath = nestedPath;
            return this;
        }
        /**
         * Adds aggregations to this search context.
         *
         * @param aggregations the aggregations.
         * @return self
         */
        public SearchRequestContextBuilder aggregations(Collection<AggregationSearchContext> aggregations) {
            this.aggregations = aggregations;
            return this;
        }
        /**
         * Adds routig hints.
         *
         * @param routings the routingHints to set
         * @return self
         */
        public SearchRequestContextBuilder routings(List<String> routings) {
            this.routings = routings;
            return this;
        }
        /**
         * Change run exits flag
         *
         * @param runExits run exits flag
         * @return self
         */
        public SearchRequestContextBuilder runExits(boolean runExits) {
            this.runExits = runExits;
            return this;
        }
        /**
         * Set values for last record in last search
         * @param searchAfter search after values
         * @return self
         */
        public SearchRequestContextBuilder searchAfter(List<Object> searchAfter) {
            this.searchAfter = searchAfter;
            return this;
        }

        public SearchRequestContextBuilder shardNumber(Integer shardNumber) {
            this.shardNumber = shardNumber;
            return this;
        }

        public void enableScore(boolean scoreEnabled) {
            this.scoreEnabled = scoreEnabled;
        }

        public SearchRequestContextBuilder nestedSearch(NestedSearchRequestContext... searches) {
            if (ArrayUtils.isNotEmpty(searches)) {

                if (nestedSearch == null) {
                    nestedSearch = new ArrayList<>();
                }

                for (int i = 0; i < searches.length; i++) {
                    if (Objects.nonNull(searches[i])) {
                        nestedSearch.add(searches[i]);
                    }
                }
            }
            return this;
        }

        public SearchRequestContextBuilder nestedSearch(Collection<NestedSearchRequestContext> searches) {
            if (CollectionUtils.isNotEmpty(searches)) {
                nestedSearch(searches.toArray(NestedSearchRequestContext[]::new));
            }
            return this;
        }

        public SearchRequestContextBuilder must(boolean must) {
            this.must = must;
            return this;
        }

        public SearchRequestContextBuilder scoreFields(Map<String, Float> scoreFields) {
            this.scoreFields = scoreFields;
            return this;
        }
        /**
         * {@inheritDoc}
         */
        @Override
        public SearchInputCollectorType getCollectorType() {
            return SearchInputCollectorType.SIMPLE;
        }
        /**
         * Builds a context from this builder.
         * @return new {@link SearchRequestContext}
         */
        public SearchRequestContext build() {
            return new SearchRequestContext(this);
        }
    }
}
